iT邦幫忙

2022 iThome 鐵人賽

DAY 13
0

今日目標,實現自定義登入功能。

驗證功能

在實現自定義功能時,我們會需要有個實例負責儲存使用者的資訊,這個實例就是 UserDetails,並且會有相關 Service 來操作它。
在驗證的流程中,當收到一組帳號密碼,會先去呼叫 UserDetailsService 看是否有這組帳號,如果存在就將相關資訊 (UserDetails) 傳給 AuthenticationProvider,做後續的驗證,當 AuthenticationProvider 驗證通過,就是合法的使用者了。
因此,我們需要分別實現 UserDetails、UserDetailsService、AuthenticationProvider,但這些方法其實都寫好一些介面了,我們只需要實作他們。

  1. 在 security 底下建立 java class,名稱為 CustomUserDetails,並使內容為:
    package com.example.security;
    
    import com.example.user.UserModel;
    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.security.core.userdetails.UserDetails;
    
    import java.util.Collection;
    
    public class CustomUserDetails implements UserDetails {
        private UserModel user;
    
        public CustomUserDetails(UserModel user) {
            this.user = user;
        }
    
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return null;
        }
    
        @Override
        public String getPassword() {
            return user.getPassword();
        }
    
        @Override
        public String getUsername() {
            return user.getUsername();
        }
    
        @Override
        public boolean isAccountNonExpired() {
            return true;
        }
    
        @Override
        public boolean isAccountNonLocked() {
            return true;
        }
    
        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }
    
        @Override
        public boolean isEnabled() {
            return true;
        }
    }
    
    • UserDetails:已經定義好功能的介面,我們只需要實作它即可
  2. 在 security 底下建立 java class,名稱為 CustomUserDetailsService,並使內容為:
    package com.example.security;
    
    import com.example.user.UserModel;
    import com.example.user.UserService;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.core.userdetails.UsernameNotFoundException;
    
    public class CustomUserDetailsService implements UserDetailsService {
        @Autowired
        UserService userService;
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            UserModel user = userService.findUserByUsername(username);
            if (user == null) {
                throw new UsernameNotFoundException("該帳號不存在");
            }
            return new CustomUserDetails(user);
        }
    }
    
    • UserDetailsService:也是已經定義好的介面
  3. 修改 WebSecurityConfig 的內容,新增 userDetailsService、authenticationProvider,並修改 configure:
    package com.example.security;
    
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
    import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
    import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
    import org.springframework.security.crypto.password.PasswordEncoder;
    
    @Configuration
    @EnableWebSecurity
    public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
        @Bean
        public UserDetailsService userDetailsService() {
            return new CustomUserDetailsService();
        }
    
        @Bean
        public DaoAuthenticationProvider authenticationProvider() {
            DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
            authProvider.setUserDetailsService(userDetailsService());
            authProvider.setPasswordEncoder(passwordEncoder());
    
            return authProvider;
        }
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        @Override
        protected void configure(AuthenticationManagerBuilder auth) throws Exception {
            auth.authenticationProvider(authenticationProvider());
        }
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.csrf().disable();
            http
                .authorizeRequests()
                    .antMatchers("/register").permitAll()
                    .anyRequest().authenticated()
                    .and()
                .formLogin();
        }
    }
    
    • userDetailsService():使用我們自定義的 user details service
    • authenticationProvider():自定義驗證提供者,並設定 password encoder
    • auth.authenticationProvider(authenticationProvider()):設定驗證提供者
  4. 接下來去 login 頁面,試試自己註冊的帳號密碼吧,應該能正常登入!

Login 頁面

雖然 spring 幫我們生成了一個 login 的頁面,但我們仍然可以自己定義 login 的頁面,接下來就是 login 頁面的配置,跟 register 差不多,建立 html、controller 控制。

  1. 在 templates 底下建立 html file,名稱為 login,內容為:
    <!DOCTYPE html>
    <html lang="en" xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
        <form action="/login" method="post">
            <input type="text" id="username" name="username" placeholder="Username">
            <input type="password" id="password" name="password" placeholder="Password">
            <div th:if="${param.error}">
                <div>帳號或密碼錯誤</div>
            </div>
            <button type="submit" class="btn btn-primary">登入</button>
        </form>
    </body>
    </html>
    
  2. 修改 UserController,完整內容為:
    package com.example.user;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.validation.BindingResult;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.servlet.mvc.support.RedirectAttributes;
    
    import javax.validation.Valid;
    import java.util.Objects;
    
    @Controller
    public class UserController {
        @Autowired
        private UserService userService;
    
        @GetMapping("/register")
        public String viewRegisterPage(Model model) {
            model.addAttribute("name", "註冊");
            model.addAttribute("user", new UserModel());
            return "register";
        }
    
        @PostMapping("/register")
        public String registerProcess(@Valid UserModel user, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
            if (bindingResult.hasErrors()) {
                String message = Objects.requireNonNull(bindingResult.getFieldError()).getDefaultMessage();
                redirectAttributes.addFlashAttribute("error", message);
                return "redirect:/register";
            }
            userService.addUser(user);
            return "redirect:/";
        }
    
        @GetMapping("/login")
        public String viewLoginPage() {
            return "login";
        }
    }
    
  3. 修改 WebSecurityConfig 的 configure,允許 login 的頁面,並指定 login 頁面的路徑,完整內容為:
    package com.example.security;
    
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
    import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
    import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
    import org.springframework.security.crypto.password.PasswordEncoder;
    
    @Configuration
    @EnableWebSecurity
    public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
        @Bean
        public UserDetailsService userDetailsService() {
            return new CustomUserDetailsService();
        }
    
        @Bean
        public DaoAuthenticationProvider authenticationProvider() {
            DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
            authProvider.setUserDetailsService(userDetailsService());
            authProvider.setPasswordEncoder(passwordEncoder());
    
            return authProvider;
        }
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        @Override
        protected void configure(AuthenticationManagerBuilder auth) throws Exception {
            auth.authenticationProvider(authenticationProvider());
        }
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.csrf().disable();
            http
                .authorizeRequests()
                    .antMatchers("/register", "/login").permitAll()
                    .anyRequest().authenticated()
                    .and()
                .formLogin()
                    .loginPage("/login")
                    .failureUrl("/login?error");
        }
    }
    
    • .loginPage("/login"):設定 login 頁面的路徑
    • .failureUrl("/login?error"):設定登入失敗的路徑,用於顯示錯誤訊息給使用者看
  4. 到 login 的頁面,就可以看到自己的頁面了 (好像原本幫忙寫好的比較好看),我們會在明天使用模板讓它變漂亮~~

登入狀態檢驗

我們之後在程式中會需要先檢驗當前使用者是否為登入狀態,所以先寫好方法,SecurityContextHolder 會負責維護當前使用者的相關資訊,藉由取得 Authentication 來檢驗是否登入,登入的條件就是 Authentication 不為 NULL,而且身分不為匿名身分(Anonymous Authentication)。

  1. 在 UserService 加入 isLogin、getUsername 函數,完整內容為:
    package com.example.user;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.security.authentication.AnonymousAuthenticationToken;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.context.SecurityContextHolder;
    import org.springframework.security.crypto.password.PasswordEncoder;
    import org.springframework.stereotype.Service;
    import org.springframework.validation.annotation.Validated;
    
    import javax.validation.ConstraintViolation;
    import javax.validation.ConstraintViolationException;
    import javax.validation.Validator;
    import java.util.Set;
    
    @Service
    @Validated
    public class UserService {
        @Autowired
        private UserRepository userRepository;
    
        @Autowired
        private Validator validator;
    
        @Autowired
        private PasswordEncoder passwordEncoder;
    
        public UserModel findUserByEmail(String email) {
            return userRepository.findByEmail(email);
        }
    
        public UserModel findUserByUsername(String username) {
            return userRepository.findByUsername(username);
        }
    
        public Integer addUser(UserModel user) {
            Set<ConstraintViolation<UserModel>> violations = validator.validate(user);
            if (!violations.isEmpty()) {
                StringBuilder sb = new StringBuilder();
                for (ConstraintViolation<UserModel> constraintViolation : violations) {
                    sb.append(constraintViolation.getMessage());
                }
                throw new ConstraintViolationException(sb.toString(), violations);
            }
            user.setPassword(passwordEncoder.encode(user.getPassword()));
            UserModel newUser = userRepository.save(user);
            return newUser.getId();
        }
    
        public boolean isLogin() {
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            return !(authentication == null || authentication instanceof AnonymousAuthenticationToken);
        }
    
        public String getUsername() {
            return SecurityContextHolder.getContext().getAuthentication().getName();
        }
    }
    
  2. 我們稍微修改一下 controller,我們希望登入後就不要給使用者進入 register 和 login 頁面,透過剛剛寫的 isLogin 來檢驗登入狀態:
    package com.example.user;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.validation.BindingResult;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.servlet.mvc.support.RedirectAttributes;
    
    import javax.validation.Valid;
    import java.util.Objects;
    
    @Controller
    public class UserController {
        @Autowired
        private UserService userService;
    
        @GetMapping("/register")
        public String viewRegisterPage(Model model) {
            if (userService.isLogin()) {
                return "redirect:/";
            }
            model.addAttribute("name", "註冊");
            model.addAttribute("user", new UserModel());
            return "register";
        }
    
        @PostMapping("/register")
        public String registerProcess(@Valid UserModel user, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
            if (bindingResult.hasErrors()) {
                String message = Objects.requireNonNull(bindingResult.getFieldError()).getDefaultMessage();
                redirectAttributes.addFlashAttribute("error", message);
                return "redirect:/register";
            }
            userService.addUser(user);
            return "redirect:/";
        }
    
        @GetMapping("/login")
        public String viewLoginPage() {
            if (userService.isLogin()) {
                return "redirect:/";
            }
            return "login";
        }
    }
    
    • 我們禁止已登入的使用者可以進入 login、register 頁面,並將其重新導到根路徑(/)
  3. 這次先登入後,再去 login 和 register 的頁面試試,會發現被重新導向了。

登出

  1. 修改 WebSecurityConfig 的 configure,完整內容為:
    package com.example.security;
    
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
    import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
    import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
    import org.springframework.security.crypto.password.PasswordEncoder;
    
    @Configuration
    @EnableWebSecurity
    public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
        @Bean
        public UserDetailsService userDetailsService() {
            return new CustomUserDetailsService();
        }
    
        @Bean
        public DaoAuthenticationProvider authenticationProvider() {
            DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
            authProvider.setUserDetailsService(userDetailsService());
            authProvider.setPasswordEncoder(passwordEncoder());
    
            return authProvider;
        }
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        @Override
        protected void configure(AuthenticationManagerBuilder auth) throws Exception {
            auth.authenticationProvider(authenticationProvider());
        }
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.csrf().disable();
            http
                .authorizeRequests()
                    .antMatchers("/register", "/login").permitAll()
                    .anyRequest().authenticated()
                    .and()
                .formLogin()
                    .loginPage("/login")
                    .failureUrl("/login?error")
                    .and()
                .logout()
                    .logoutSuccessUrl("/login").permitAll();
        }
    }
    
    • .logout():設定 logout 的動作
    • .logoutSuccessUrl("/login").permitAll():登出後跳轉到 login 頁面
  2. 在登入後,嘗試去 http://127.0.0.1:8080/logout 應該會發現被登出了,然後也能正常進入 login 頁面~~

今天的東西看似很多,但其實大部分 spring security 都幫我們寫好一部分了,所以比較容易一些 (複製貼上就好),只有觀念上需要學習一下。 (最喜歡這種別人都幫忙弄好的功能) /images/emoticon/emoticon34.gif


上一篇
Day 11 - Web Security Config
下一篇
Day 13 - 頁面模板 Thymeleaf Page Layout
系列文
Spring Boot... 深不可測31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言